From 8dc6c5716887495e0b98431a0c5b72959ed6d62d Mon Sep 17 00:00:00 2001
From: Christoph Berg <myon@debian.org>
Date: Tue, 28 Apr 2026 13:57:34 +0200
Subject: [PATCH v7 4/5] Add dates on badgeholders so people can be retired

---
 pgweb/contributors/admin.py                   |  3 +-
 pgweb/contributors/forms.py                   | 38 +++++++---
 ..._badge_alter_contributor_ctype_and_more.py | 76 +++++++++++++++++++
 pgweb/contributors/models.py                  | 25 +++++-
 pgweb/contributors/views.py                   | 10 +--
 templates/contributors/badge.html             |  4 +-
 templates/contributors/profile.html           |  6 +-
 7 files changed, 138 insertions(+), 24 deletions(-)
 create mode 100644 pgweb/contributors/migrations/0004_badge_alter_contributor_ctype_and_more.py

diff --git a/pgweb/contributors/admin.py b/pgweb/contributors/admin.py
index 71024b36..9bedc4cd 100644
--- a/pgweb/contributors/admin.py
+++ b/pgweb/contributors/admin.py
@@ -2,7 +2,7 @@ from django import forms
 from django.contrib import admin
 from django.db.models import Count
 
-from .models import Contributor, ContributorType, Badge
+from .models import Contributor, ContributorType, Badge, Badgeholder
 
 
 class ContributorAdminForm(forms.ModelForm):
@@ -40,3 +40,4 @@ class BadgeAdmin(admin.ModelAdmin):
 admin.site.register(ContributorType)
 admin.site.register(Contributor, ContributorAdmin)
 admin.site.register(Badge, BadgeAdmin)
+admin.site.register(Badgeholder)
diff --git a/pgweb/contributors/forms.py b/pgweb/contributors/forms.py
index 54dbdd23..afa57d0e 100644
--- a/pgweb/contributors/forms.py
+++ b/pgweb/contributors/forms.py
@@ -3,12 +3,14 @@ from django.forms import ValidationError
 from django.conf import settings
 
 from pgweb.core.models import Organisation
-from .models import Contributor, Badge
+from .models import Contributor, Badge, Badgeholder
 from django.contrib.auth.models import User
 
 from pgweb.util.middleware import get_current_user
 from pgweb.mailqueue.util import send_simple_mail
 
+from datetime import date
+
 
 class UserModelMultipleChoiceField(forms.ModelMultipleChoiceField):
     def label_from_instance(self, obj):
@@ -22,8 +24,9 @@ class UserModelMultipleChoiceField(forms.ModelMultipleChoiceField):
 class BadgeForm(forms.ModelForm):
     form_intro = 'Contributor badges acknowledge people contributing time to the PostgreSQL project and the ecosystem around it. If you manage an organisation that gives people the opportunity to contribute (like volunteering or speaking at a conference, writing code for an extension, translating messages, helping others use PostgreSQL, organise the community, ...), you can issue a badge to acknowledge these contributions.'
 
-    remove_holder = UserModelMultipleChoiceField(required=False, queryset=None, label="Current badge holders", help_text="Select one or more users to remove")
     add_holder = forms.CharField(required=False, help_text="Enter email addresses of postgresql.org user accounts to award the badge to. Separate multiple addresses with whitespace.")
+    retire_holder = UserModelMultipleChoiceField(required=False, queryset=None, label="Current badge holders", help_text="Select one or more users to retire.")
+    remove_holder = UserModelMultipleChoiceField(required=False, queryset=None, label="Retired badge holders", help_text="Select one or more users to remove. To un-retire a user, add their email address again in the first field.")
 
     fieldsets = [
         {
@@ -34,7 +37,7 @@ class BadgeForm(forms.ModelForm):
         {
             'id': 'holders',
             'legend': 'Badge Holders',
-            'fields': ['remove_holder', 'add_holder'],
+            'fields': ['add_holder', 'retire_holder', 'remove_holder'],
         },
     ]
 
@@ -45,10 +48,12 @@ class BadgeForm(forms.ModelForm):
     def __init__(self, *args, **kwargs):
         super(BadgeForm, self).__init__(*args, **kwargs)
         if self.instance and self.instance.pk:
-            self.fields['remove_holder'].queryset = self.instance.holders
+            self.fields['retire_holder'].queryset = self.instance.holders.filter(badgeholder__date_retired__isnull=True)
+            self.fields['remove_holder'].queryset = self.instance.holders.filter(badgeholder__date_retired__isnull=False)
         else:
-            del self.fields['remove_holder']
             del self.fields['add_holder']
+            del self.fields['retire_holder']
+            del self.fields['remove_holder']
             # remove the holders fieldset
             self.fieldsets = [fs for fs in self.fieldsets if fs['id'] != 'holders']
 
@@ -64,29 +69,38 @@ class BadgeForm(forms.ModelForm):
         return self.cleaned_data['add_holder']
 
     def save(self, commit=True):
-        model = super(BadgeForm, self).save(commit=False)
+        badge = super(BadgeForm, self).save(commit=False)
 
         ops = []
 
         if 'add_holder' in self.cleaned_data and self.cleaned_data['add_holder']:
             for u in self.cleaned_data['add_holder'].split():
                 user = User.objects.get(email=u.lower())
-                model.holders.add(user)
-                ops.append('Added badge holder {}'.format(user.username))
+                holder = Badgeholder.objects.get_or_create(user=user, badge=badge, awarded_by=get_current_user())[0]
+                if holder.date_retired:  # un-retire them
+                    holder.date_retired = None
+                    holder.save()
+                    ops.append('Un-retired badge holder {}'.format(user.username))
+                else:
+                    ops.append('Added badge holder {}'.format(user.username))
+        if 'retire_holder' in self.cleaned_data and self.cleaned_data['retire_holder']:
+            for toretire in self.cleaned_data['retire_holder']:
+                Badgeholder.objects.filter(user=toretire).update(date_retired=date.today())
+                ops.append('Retired badge holder {}'.format(toretire.username))
         if 'remove_holder' in self.cleaned_data and self.cleaned_data['remove_holder']:
             for toremove in self.cleaned_data['remove_holder']:
-                model.holders.remove(toremove)
+                badge.holders.remove(toremove)
                 ops.append('Removed badge holder {}'.format(toremove.username))
 
         if ops:
             send_simple_mail(
                 settings.NOTIFICATION_FROM,
                 settings.NOTIFICATION_EMAIL,
-                "{0} modified {1}".format(get_current_user().username, model),
-                "The following changes were made to {}:\n\n{}".format(model, "\n".join(ops))
+                "{0} modified {1}".format(get_current_user().username, badge),
+                "The following changes were made to {}:\n\n{}".format(badge, "\n".join(ops))
             )
 
-        return model
+        return badge
 
     def filter_by_user(self, user):
         self.fields['org'].queryset = Organisation.objects.filter(managers=user, approved=True)
diff --git a/pgweb/contributors/migrations/0004_badge_alter_contributor_ctype_and_more.py b/pgweb/contributors/migrations/0004_badge_alter_contributor_ctype_and_more.py
new file mode 100644
index 00000000..8cc82068
--- /dev/null
+++ b/pgweb/contributors/migrations/0004_badge_alter_contributor_ctype_and_more.py
@@ -0,0 +1,76 @@
+# Generated by Django 4.2.30 on 2026-04-28 13:53
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0007_usersubmissions'),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('contributors', '0003_make_email_nullable'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Badge',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('approved', models.BooleanField(default=False)),
+                ('badge', models.CharField(help_text='Title of this badge, e.g. "PGConf.EU 2025 Speaker".', max_length=32, unique=True)),
+                ('description', models.TextField(blank=True, help_text='What did the people do who contributed here?', null=True)),
+                ('url', models.URLField(blank=True, help_text='URL for this contribution, e.g. the conference homepage. (Leave blank when there is no URL.)', max_length=100, null=True, verbose_name='Contribution URL')),
+                ('image', models.CharField(blank=True, help_text='Badge images should be square (usually shown at 150x150 pixels). When left blank, the Slony logo will be used. External URLs work, but preferably the image should be hosted on postgresql.org. Mail the Contributors team to have your image added.', max_length=100, null=True, verbose_name='Path to contribution image')),
+                ('contact', models.CharField(blank=True, help_text='Contact address (email, URL, other) for people who want to be added as badge holder', max_length=100, null=True, verbose_name='Contact address')),
+                ('sortorder', models.IntegerField(blank=True, default=100, null=True)),
+            ],
+            options={
+                'ordering': ('sortorder', 'badge'),
+            },
+        ),
+        migrations.AlterField(
+            model_name='contributor',
+            name='ctype',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contributors.contributortype', verbose_name='Contributor Type'),
+        ),
+        migrations.AlterField(
+            model_name='contributor',
+            name='email',
+            field=models.EmailField(blank=True, max_length=254, null=True),
+        ),
+        migrations.AlterField(
+            model_name='contributor',
+            name='user',
+            field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
+        ),
+        migrations.CreateModel(
+            name='Badgeholder',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('date_awarded', models.DateField(auto_now_add=True)),
+                ('date_retired', models.DateField(blank=True, null=True)),
+                ('awarded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='awarded_by', to=settings.AUTH_USER_MODEL)),
+                ('badge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contributors.badge')),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'ordering': ('badge__sortorder', 'badge', 'user__last_name', 'user__first_name'),
+            },
+        ),
+        migrations.AddField(
+            model_name='badge',
+            name='holders',
+            field=models.ManyToManyField(blank=True, through='contributors.Badgeholder', to=settings.AUTH_USER_MODEL),
+        ),
+        migrations.AddField(
+            model_name='badge',
+            name='org',
+            field=models.ForeignKey(help_text='If no organisations are listed here, please check the <a href="/account/orglist/">organisation list</a> and contact the organisation manager or <a href="mailto:webmaster@postgresql.org">webmaster@postgresql.org</a> if no managers are listed. If your organisation is not listed, you can <a href="/account/edit/organisations/">create it</a>.', on_delete=django.db.models.deletion.CASCADE, to='core.organisation', verbose_name='Organisation'),
+        ),
+        migrations.AddConstraint(
+            model_name='badgeholder',
+            constraint=models.UniqueConstraint(fields=('user', 'badge'), name='unique_user_badge'),
+        ),
+    ]
diff --git a/pgweb/contributors/models.py b/pgweb/contributors/models.py
index 4a0532ba..028b6bb3 100644
--- a/pgweb/contributors/models.py
+++ b/pgweb/contributors/models.py
@@ -52,7 +52,7 @@ class Badge(TwostateModerateModel):
     url = models.URLField(max_length=100, null=True, blank=True, verbose_name='Contribution URL', help_text='URL for this contribution, e.g. the conference homepage. (Leave blank when there is no URL.)')
     image = models.CharField(max_length=100, verbose_name='Path to contribution image', null=True, blank=True, help_text="Badge images should be square (usually shown at 150x150 pixels). When left blank, the Slony logo will be used. External URLs work, but preferably the image should be hosted on postgresql.org. Mail the Contributors team to have your image added.")
     contact = models.CharField(max_length=100, null=True, blank=True, verbose_name='Contact address', help_text='Contact address (email, URL, other) for people who want to be added as badge holder')
-    holders = models.ManyToManyField(User, blank=True)
+    holders = models.ManyToManyField(User, through="Badgeholder", through_fields=("badge", "user"), blank=True)
 
     sortorder = models.IntegerField(null=True, blank=True, default=100)
 
@@ -78,3 +78,26 @@ class Badge(TwostateModerateModel):
     def get_formclass(self):
         from pgweb.contributors.forms import BadgeForm
         return BadgeForm
+
+
+class Badgeholder(models.Model):
+    user = models.ForeignKey(User, on_delete=models.CASCADE)
+    badge = models.ForeignKey(Badge, on_delete=models.CASCADE)
+
+    date_awarded = models.DateField(auto_now_add=True)
+    date_retired = models.DateField(null=True, blank=True)
+    awarded_by = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL, related_name="awarded_by")
+
+    purge_urls = ('/community/people/', '/community/badge/')
+
+    def __str__(self):
+        label = f"{self.badge} {self.user.first_name} {self.user.last_name} ({self.user})"
+        if self.date_retired:
+            label += " (retired)"
+        return label
+
+    class Meta:
+        ordering = ('badge__sortorder', 'badge', 'user__last_name', 'user__first_name')
+        constraints = [
+            models.UniqueConstraint(fields=["user", "badge"], name="unique_user_badge")
+        ]
diff --git a/pgweb/contributors/views.py b/pgweb/contributors/views.py
index ee0124ac..e22136b3 100644
--- a/pgweb/contributors/views.py
+++ b/pgweb/contributors/views.py
@@ -2,7 +2,7 @@ from django.shortcuts import get_object_or_404
 
 from pgweb.util.contexts import render_pgweb
 
-from .models import ContributorType, Contributor, Badge
+from .models import ContributorType, Contributor, Badge, Badgeholder
 
 
 def completelist(request):
@@ -24,9 +24,9 @@ def peoplelist(request):
 def badge_view(request, badgeid):
     badge = get_object_or_404(Badge, id=badgeid, approved=True)
     # badge holders are users, but we want to show only users with a contributor object here
-    holders = badge.holders.filter(contributor__isnull=False). \
-        prefetch_related('contributor'). \
-        order_by('contributor__lastname', 'contributor__firstname')
+    holders = Badgeholder.objects.filter(badge=badge, user__contributor__isnull=False). \
+        prefetch_related('user__contributor'). \
+        order_by('user__contributor__lastname', 'user__contributor__firstname')
     return render_pgweb(request, 'community', 'contributors/badge.html', {
         'badge': badge,
         'holders': holders,
@@ -35,7 +35,7 @@ def badge_view(request, badgeid):
 
 def profile(request, username):
     contributor = get_object_or_404(Contributor, user__username=username)
-    badges = Badge.objects.filter(approved=True, holders=contributor.user)
+    badges = Badgeholder.objects.filter(user=contributor.user, badge__approved=True)
     return render_pgweb(request, 'community', 'contributors/profile.html', {
         'contributor': contributor,
         'badges': badges,
diff --git a/templates/contributors/badge.html b/templates/contributors/badge.html
index 16c995ca..9d07b28e 100644
--- a/templates/contributors/badge.html
+++ b/templates/contributors/badge.html
@@ -19,12 +19,12 @@
 <h2>Badge Holders</h2>
   {%if holders|length > 50%}
     {%for u in holders.all %}
-      <nobr><a href="/community/people/{{u}}/">{{u.first_name}} {{u.last_name}}</a> {%if not forloop.last%}&middot;{%endif%}</nobr>
+      <nobr>{%if u.date_retired%}<em>{%endif%}<a href="/community/people/{{u.user}}/">{{u.user.first_name}} {{u.user.last_name}}</a>{%if u.date_retired%}</em>{%endif%} {%if not forloop.last%}&middot;{%endif%}</nobr>
     {%endfor%}
   {%else%}
     <ul>
       {%for u in holders.all%}
-        <li><a href="/community/people/{{u}}/">{{u.first_name}} {{u.last_name}}</a></li>
+        <li>{%if u.date_retired%}<em>{%endif%}<a href="/community/people/{{u.user}}/">{{u.user.first_name}} {{u.user.last_name}}</a>{%if u.date_retired%} (retired from this activity)</em>{%endif%}</li>
       {%endfor%}
     </ul>
   {%endif%}
diff --git a/templates/contributors/profile.html b/templates/contributors/profile.html
index eca38dc2..674c2648 100644
--- a/templates/contributors/profile.html
+++ b/templates/contributors/profile.html
@@ -11,9 +11,9 @@
       <div class="badges-grid">
       {%for b in badges %}
         <figure class="badges-cell">
-          <a href="/community/badge/{{b.id}}/">
-          <img src="{%if b.image %}{{b.image}}{%else%}/media/img/about/press/elephant.png{%endif%}">
-          <figcaption>{{b}}</figcaption>
+          <a href="/community/badge/{{b.badge.id}}/">
+          <img src="{%if b.badge.image %}{{b.badge.image}}{%else%}/media/img/about/press/elephant.png{%endif%}">
+          <figcaption>{{b.badge}}{%if b.date_retired%}<br><em>(retired)</em>{%endif%}</figcaption>
           </a>
         </figure>
       {%endfor%}
-- 
2.53.0

